import 'dart:math';

import 'package:meta/meta.dart';
import 'package:source_span/source_span.dart';
import 'package:sqlparser/sqlparser.dart';

enum TokenType {
  /// A token representing an invalid lexeme in the source.
  errorToken,

  $case,
  $default,
  $do,
  $else,
  $false,
  $for,
  $if,
  $in,
  $index,
  $is,
  $null,
  $true,
  $values,
  $with,
  abort,
  action,
  add,
  after,
  all,
  alter,
  always,
  ampersand,
  analyze,
  and,
  any,
  array,
  as,
  asc,
  asymmetric,
  atSignVariable,
  attach,
  autoincrement,
  before,
  begin,
  between,
  binary,
  both,
  by,
  cascade,
  cast,
  check,
  collate,
  colon,
  column,
  colonVariable,
  comma,
  comment,
  commit,
  conflict,
  constraint,
  create,
  cross,
  current,
  currentDate,
  currentTime,
  currentTimestamp,
  currentUser,
  database,

  /// `->`, extract subcomponent of JSON
  dashRangle,

  /// `->>`, extract subcomponent of JSON as SQL value.
  dashRangleRangle,
  deferrable,
  deferred,
  delete,
  desc,
  detach,
  distinct,
  dollarSignVariable,
  dot,
  doubleEqual,
  doublePipe,
  drop,
  each,
  end,
  eof,
  equal,
  escape,
  except,
  exclamationEqual,
  exclude,
  exclusive,
  exists,
  explain,
  fail,
  filter,
  first,
  following,
  foreign,
  from,
  full,
  generated,
  glob,
  grant,
  group,
  groups,
  having,
  identifier,
  ignore,
  ilike,
  immediate,
  indexed,
  initially,
  inner,
  insert,
  instead,
  intersect,
  into,
  isNull,
  join,
  key,
  last,
  leading,
  left,
  leftParen,
  less,
  lessEqual,
  lessMore,
  like,
  limit,
  localTime,
  localTimestamp,
  match,
  materialized,
  minus,
  more,
  moreEqual,
  natural,
  no,
  not,
  notNull,
  nothing,
  nulls,
  numberLiteral,
  of,
  offset,
  on,
  only,
  or,
  order,
  others,
  outer,
  over,
  overlaps,
  partition,
  placing,
  percent,
  pipe,
  plan,
  plus,
  pragma,
  preceding,
  primary,
  query,
  questionMarkVariable,
  range,
  raise,
  recursive,
  references,
  regexp,
  reindex,
  release,
  rename,
  replace,
  returning,
  restrict,
  right,
  rightParen,
  rollback,
  row,
  rowid,
  rows,
  savepoint,
  select,
  semicolon,
  set,
  sessionUser,
  shiftLeft,
  shiftRight,
  similar,
  slash,
  some,
  stored,
  star,
  strict,
  symmetric,
  stringLiteral,
  table,
  temp,
  temporary,
  then,
  ties,
  tilde,
  to,
  trailing,
  transaction,
  trigger,
  unbounded,
  union,
  unique,
  update,
  user,
  using,
  vacuum,
  view,
  virtual,
  when,
  where,
  window,
  without,

  /// Drift specific token, used to declare type converter
  mapped,
  inlineDart,
  import,
  json,
  required,
  list,

  /// A `**` token. This is only scanned when scanning for drift tokens.
  doubleStar,
}

const Map<String, TokenType> keywords = {
  'ADD': TokenType.add,
  'ABORT': TokenType.abort,
  'ACTION': TokenType.action,
  'AFTER': TokenType.after,
  'ALL': TokenType.all,
  'ALTER': TokenType.alter,
  'ALWAYS': TokenType.always,
  'ANALYZE': TokenType.analyze,
  'AND': TokenType.and,
  'AS': TokenType.as,
  'ASC': TokenType.asc,
  'ATTACH': TokenType.attach,
  'AUTOINCREMENT': TokenType.autoincrement,
  'BEFORE': TokenType.before,
  'BEGIN': TokenType.begin,
  'BETWEEN': TokenType.between,
  'BY': TokenType.by,
  'CASCADE': TokenType.cascade,
  'CASE': TokenType.$case,
  'CAST': TokenType.cast,
  'CHECK': TokenType.check,
  'COLLATE': TokenType.collate,
  'COLUMN': TokenType.column,
  'COMMIT': TokenType.commit,
  'CONFLICT': TokenType.conflict,
  'CONSTRAINT': TokenType.constraint,
  'CREATE': TokenType.create,
  'CROSS': TokenType.cross,
  'CURRENT': TokenType.current,
  'CURRENT_DATE': TokenType.currentDate,
  'CURRENT_TIME': TokenType.currentTime,
  'CURRENT_TIMESTAMP': TokenType.currentTimestamp,
  'DATABASE': TokenType.database,
  'DEFAULT': TokenType.$default,
  'DEFERRABLE': TokenType.deferrable,
  'DEFERRED': TokenType.deferred,
  'DELETE': TokenType.delete,
  'DESC': TokenType.desc,
  'DETACH': TokenType.detach,
  'DISTINCT': TokenType.distinct,
  'DO': TokenType.$do,
  'DROP': TokenType.drop,
  'EACH': TokenType.each,
  'ELSE': TokenType.$else,
  'END': TokenType.end,
  'ESCAPE': TokenType.escape,
  'EXCEPT': TokenType.except,
  'EXCLUDE': TokenType.exclude,
  'EXCLUSIVE': TokenType.exclusive,
  'EXISTS': TokenType.exists,
  'EXPLAIN': TokenType.explain,
  'FAIL': TokenType.fail,
  'FALSE': TokenType.$false,
  'FILTER': TokenType.filter,
  'FIRST': TokenType.first,
  'FOLLOWING': TokenType.following,
  'FOR': TokenType.$for,
  'FOREIGN': TokenType.foreign,
  'FROM': TokenType.from,
  'FULL': TokenType.full,
  'GENERATED': TokenType.generated,
  'GLOB': TokenType.glob,
  'GROUP': TokenType.group,
  'GROUPS': TokenType.groups,
  'HAVING': TokenType.having,
  'IF': TokenType.$if,
  'IGNORE': TokenType.ignore,
  'IMMEDIATE': TokenType.immediate,
  'IN': TokenType.$in,
  'INDEX': TokenType.$index,
  'INDEXED': TokenType.indexed,
  'INITIALLY': TokenType.initially,
  'INNER': TokenType.inner,
  'INSERT': TokenType.insert,
  'INSTEAD': TokenType.instead,
  'INTERSECT': TokenType.intersect,
  'INTO': TokenType.into,
  'IS': TokenType.$is,
  'ISNULL': TokenType.isNull,
  'JOIN': TokenType.join,
  'KEY': TokenType.key,
  'LAST': TokenType.last,
  'LEADING': TokenType.leading,
  'LEFT': TokenType.left,
  'LIKE': TokenType.like,
  'LIMIT': TokenType.limit,
  'MATCH': TokenType.match,
  'MATERIALIZED': TokenType.materialized,
  'NATURAL': TokenType.natural,
  'NO': TokenType.no,
  'NOT': TokenType.not,
  'NOTHING': TokenType.nothing,
  'NOTNULL': TokenType.notNull,
  'NULL': TokenType.$null,
  'NULLS': TokenType.nulls,
  'OF': TokenType.of,
  'OFFSET': TokenType.offset,
  'ON': TokenType.on,
  'OR': TokenType.or,
  'ORDER': TokenType.order,
  'OTHERS': TokenType.others,
  'OUTER': TokenType.outer,
  'OVER': TokenType.over,
  'PARTITION': TokenType.partition,
  'PLAN': TokenType.plan,
  'PRAGMA': TokenType.pragma,
  'PRECEDING': TokenType.preceding,
  'PRIMARY': TokenType.primary,
  'QUERY': TokenType.query,
  'RAISE': TokenType.raise,
  'RANGE': TokenType.range,
  'RECURSIVE': TokenType.recursive,
  'REFERENCES': TokenType.references,
  'REGEXP': TokenType.regexp,
  'REINDEX': TokenType.reindex,
  'RELEASE': TokenType.release,
  'RENAME': TokenType.rename,
  'REPLACE': TokenType.replace,
  'RIGHT': TokenType.right,
  'RETURNING': TokenType.returning,
  'RESTRICT': TokenType.restrict,
  'ROLLBACK': TokenType.rollback,
  'ROW': TokenType.row,
  'ROWID': TokenType.rowid,
  'ROWS': TokenType.rows,
  'SAVEPOINT': TokenType.savepoint,
  'SELECT': TokenType.select,
  'SET': TokenType.set,
  'STORED': TokenType.stored,
  'STRICT': TokenType.strict,
  'TABLE': TokenType.table,
  'TEMP': TokenType.temp,
  'TEMPORARY': TokenType.temporary,
  'THEN': TokenType.then,
  'TIES': TokenType.ties,
  'TO': TokenType.to,
  'TRANSACTION': TokenType.transaction,
  'TRIGGER': TokenType.trigger,
  'TRUE': TokenType.$true,
  'UNBOUNDED': TokenType.unbounded,
  'UNION': TokenType.union,
  'UNIQUE': TokenType.unique,
  'UPDATE': TokenType.update,
  'USING': TokenType.using,
  'VACUUM': TokenType.vacuum,
  'VALUES': TokenType.$values,
  'VIEW': TokenType.view,
  'VIRTUAL': TokenType.virtual,
  'WHEN': TokenType.when,
  'WHERE': TokenType.where,
  'WINDOW': TokenType.window,
  'WITH': TokenType.$with,
  'WITHOUT': TokenType.without,
};

const Map<String, TokenType> postgresKeywords = {
  'ANY': TokenType.any,
  'ARRAY': TokenType.array,
  'ASYMMETRIC': TokenType.asymmetric,
  'BINARY': TokenType.binary,
  'BOTH': TokenType.both,
  'CURRENT_USER': TokenType.currentUser,
  'ILIKE': TokenType.ilike,
  'LEADING': TokenType.leading,
  'LOCALTIME': TokenType.localTime,
  'LOCALTIMESTAMP': TokenType.localTimestamp,
  'GRANT': TokenType.grant,
  'ONLY': TokenType.only,
  'OVERLAPS': TokenType.overlaps,
  'PLACING': TokenType.placing,
  'SESSION_USER': TokenType.sessionUser,
  'SIMILAR': TokenType.similar,
  'SOME': TokenType.some,
  'SYMMETRIC': TokenType.symmetric,
  'TRAILING': TokenType.trailing,
  'USER': TokenType.user,
};

/// Maps [TokenType]s which are keywords to their lexeme.
final Map<TokenType, String> reverseKeywords = {
  for (var entry in keywords.entries) entry.value: entry.key,
  for (var entry in driftKeywords.entries) entry.value: entry.key,
};

const Map<String, TokenType> driftKeywords = {
  'IMPORT': TokenType.import,
  'JSON': TokenType.json,
  'MAPPED': TokenType.mapped,
  'REQUIRED': TokenType.required,
  'LIST': TokenType.list,
};

/// A set of [TokenType]s that can be parsed as an identifier.
const Set<TokenType> _identifierKeywords = {
  TokenType.join,
  TokenType.rowid,
};

/// Returns true if the [type] belongs to a keyword
bool isKeyword(TokenType type) => reverseKeywords.containsKey(type);

/// Returns true if [name] is a reserved keyword in sqlite.
bool isKeywordLexeme(String name) => keywords.containsKey(name.toUpperCase());

/// Returns true if [name] is a reserved keyword in postgres.
bool isPostgresKeywordLexeme(String name) =>
    postgresKeywords.containsKey(name.toUpperCase());

class Token implements SyntacticEntity {
  final TokenType type;

  /// Whether this token should be invisible to the parser. We use this for
  /// comment tokens.
  bool get invisibleToParser => false;

  @override
  final FileSpan span;
  String get lexeme => span.text;

  /// The index of this [Token] in the list of tokens scanned.
  late int index;

  Token? previous, next;

  /// For opening tokens (e.g. `(`), the matching closing token (`)`), and
  /// vice-versa.
  ///
  /// This information can be used to improve error recovery in the parser
  /// later.
  Token? match;

  Token(this.type, this.span);

  @override
  bool get hasSpan => true;

  @override
  String toString() {
    return '$type: $lexeme';
  }

  @override
  int get firstPosition => span.start.offset;

  @override
  int get lastPosition => span.end.offset;

  @override
  bool get synthetic => false;
}

class StringLiteralToken extends Token {
  final String value;

  /// sqlite allows binary strings (x'literal') which are interpreted as blobs.
  final bool binary;

  StringLiteralToken(this.value, FileSpan span, {this.binary = false})
      : super(TokenType.stringLiteral, span);
}

class IdentifierToken extends Token {
  /// Whether this identifier was escaped by putting it in "double ticks".
  final bool escaped;

  /// Whether this identifier token is synthetic. We sometimes convert
  /// [KeywordToken]s to identifiers if they're unambiguous, in which case
  /// [synthetic] will be true on this token because it was not scanned as such.
  @override
  final bool synthetic;

  String get identifier {
    if (escaped) {
      return lexeme.substring(1, lexeme.length - 1);
    } else {
      return lexeme;
    }
  }

  IdentifierToken(this.escaped, FileSpan span, {this.synthetic = false})
      : super(TokenType.identifier, span);
}

sealed class VariableToken extends Token {
  VariableToken(super.type, super.span);
}

sealed class NamedVariableToken extends VariableToken {
  /// The name of this variable, not including the [prefix].
  final String name;

  /// The prefix introducing this variable, specific to the token type. E.g.
  /// ":" for [ColonVariableToken].
  String get prefix;

  String get fullName => prefix + name;

  NamedVariableToken(this.name, super.type, super.span);
}

class QuestionMarkVariableToken extends VariableToken {
  /// The explicit index, if this variable was of the form `?123`. Otherwise
  /// null.
  final int? explicitIndex;

  QuestionMarkVariableToken(FileSpan span, this.explicitIndex)
      : super(TokenType.questionMarkVariable, span);
}

class ColonVariableToken extends NamedVariableToken {
  @override
  String get prefix => ':';

  ColonVariableToken(FileSpan span, String name)
      : super(name, TokenType.colonVariable, span);
}

class DollarSignVariableToken extends NamedVariableToken {
  @override
  String get prefix => r'$';

  DollarSignVariableToken(FileSpan span, String name)
      : super(name, TokenType.dollarSignVariable, span);
}

class AtSignVariableToken extends NamedVariableToken {
  @override
  String get prefix => '@';

  AtSignVariableToken(FileSpan span, String name)
      : super(name, TokenType.atSignVariable, span);
}

/// Inline Dart appearing in a create table statement. Only parsed when the
/// drift extensions are enabled. Dart code is wrapped in backticks.
class InlineDartToken extends Token {
  InlineDartToken(FileSpan span) : super(TokenType.inlineDart, span);

  String get dartCode {
    // strip the backticks
    return lexeme.substring(1, lexeme.length - 1);
  }
}

/// Used for tokens that are keywords. We use this special class without any
/// additional properties to ease syntax highlighting, as it allows us to find
/// the keywords easily.
class KeywordToken extends Token {
  /// Whether this token has been used as an identifier while parsing.
  bool isIdentifier = false;

  KeywordToken(super.type, super.span);

  bool canConvertToIdentifier() {
    // https://stackoverflow.com/a/45775719, but we don't parse indexed yet.
    return _identifierKeywords.contains(type) ||
        driftKeywords.values.contains(type);
  }

  IdentifierToken convertToIdentifier() {
    isIdentifier = true;

    return IdentifierToken(false, span, synthetic: true);
  }
}

/// Used to represent additional information of [TokenType.numberLiteral].
///
/// For more details, see the docs on https://www.sqlite.org/syntax/numeric-literal.html
class NumericToken extends Token {
  /// The digits before the decimal point, or null if this numeric token was
  /// written in hexadecimal notation or started with a decimal point.
  String? digitsBeforeDecimal;

  /// Whether this token has a decimal point in it.
  bool hasDecimalPoint;

  /// The digits after the decimal point, or null if this numeric token doesn't
  /// have anything after its decimal point.
  String? digitsAfterDecimal;

  /// The hexadecimal digits of this token, or null if this token was not in
  /// hex notation.
  String? hexDigits;

  /// An exponent to the base of ten.
  ///
  /// For instance, `2E-2` has an [exponent] of `-2`.
  final int? exponent;

  NumericToken(
    FileSpan span, {
    this.digitsBeforeDecimal,
    this.hasDecimalPoint = false,
    this.digitsAfterDecimal,
    this.hexDigits,
    this.exponent,
  }) : super(TokenType.numberLiteral, span);

  /// The numeric literal represented by this token.
  num get parsedNumber {
    if (hexDigits != null) {
      return int.parse(hexDigits!, radix: 16);
    }

    final beforeDecimal =
        digitsBeforeDecimal != null ? int.parse(digitsBeforeDecimal!) : 0;

    num number;

    if (!hasDecimalPoint) {
      number = beforeDecimal;
    } else if (digitsAfterDecimal != null) {
      number = beforeDecimal + double.parse('.$digitsAfterDecimal');
    } else {
      // Is of the form 3., so just infer as double
      number = beforeDecimal.toDouble();
    }

    if (exponent != null) {
      number *= pow(10, exponent!);
    }
    return number;
  }

  @visibleForTesting
  bool hasSameStructureAs(NumericToken other) {
    return other.digitsBeforeDecimal == digitsBeforeDecimal &&
        other.hasDecimalPoint == hasDecimalPoint &&
        other.digitsAfterDecimal == digitsAfterDecimal &&
        other.hexDigits == hexDigits &&
        other.exponent == exponent;
  }

  @override
  String toString() {
    final buffer = StringBuffer();
    if (hexDigits != null) {
      buffer
        ..write('0x')
        ..write(hexDigits);
    } else {
      if (digitsBeforeDecimal != null) {
        buffer.write(digitsBeforeDecimal);
      }
      if (hasDecimalPoint) {
        buffer.write('.');
      }
      if (digitsAfterDecimal != null) {
        buffer.write(digitsAfterDecimal);
      }

      if (exponent != null) {
        buffer
          ..write('E')
          ..write(exponent);
      }
    }

    return buffer.toString();
  }
}

enum CommentMode { line, cStyle }

/// A comment, either started with -- or with /*.
class CommentToken extends Token {
  final CommentMode mode;

  /// The content of this comment, excluding the "--", "/*", "*/".
  final String content;

  @override
  final bool invisibleToParser = true;

  CommentToken(this.mode, this.content, FileSpan span)
      : super(TokenType.comment, span);
}

class TokenizerError extends Token {
  final String message;
  final FileLocation location;

  TokenizerError(this.message, this.location)
      : super(TokenType.errorToken, location.pointSpan());

  @override
  String toString() {
    return '$message at $location';
  }
}

/// Thrown by the sql engine when a sql statement can't be tokenized.
class CumulatedTokenizerException implements Exception {
  final List<TokenizerError> errors;
  CumulatedTokenizerException(this.errors);

  @override
  String toString() {
    final explanation =
        errors.map((e) => '${e.message} at ${e.location}').join(', ');
    return 'Malformed sql: $explanation';
  }
}
